import random

#===============================================================================
# Base class for all generator classes. 
#===============================================================================
# Implements much of the logic of random object generation. Subclasses can work
# with some arbitrary internal representation and must implement the functions to
# mutate the current state aiming for an increased or decreased complexity level.
#
class Generator:
    '''
    Abstract class that represents a generic random object generator that 
    targets a given integer-valued level. 
    
    It is primarily used to generate mathematical objects within a given 
    level bound. Subclasses can easily implement generators for random numbers,
    polynomials, functions, etc. 
    
    Subclasses should override at least the ``increase_propose`` and 
    ``decrease_propose`` methods. It should be useful to also override the 
    ``export`` and ``level`` methods. 
    '''

    def __init__(self, start_value, target=3):
        self.target = 3
        self.value = start_value
        self._results = {}
        self._levels = {}
        self._level = self.level(self.value)

    def export(self, x):
        '''Transforms the internal value to a proper object to be exported.'''
        return x

    def increase_propose(self):
        '''Propose a new object that tries to increase the current level'''

        raise NotImplementedError('Must be implemented in child classes')

    def decrease_propose(self):
        '''Propose a new object that tries to decrease the current level'''
        raise NotImplementedError('Must be implemented in child classes')

    def increase_update(self):
        '''
        Modifies the current state in an attempt of increasing the object
        level
        '''
        self.value = new = self.increase_propose()
        self._level = self.level(new)
        return new

    def decrease_update(self):
        '''
        Modifies the current state in an attempt of decreasing the object
        level
        '''
        self.value = new = self.decrease_propose()
        self._level = self.level(new)
        return new

    def register_value(self, value, compl=None):
        '''
        Register a value and its level in the cache dictionaries.
        
        If ``compl`` is not given, it is calculated from value. 
        '''
        # Register in results
        try:
            return self._results[value]
        except KeyError:
            num_c = compl if compl is not None else self.level(value)
            num_c = round(num_c)
            self._results[value] = num_c

        # Add value to level table
        values = self._levels.setdefault(num_c, [])
        values.append(value)
        return num_c

    def suggest_new(self):
        '''
        Suggest a new random object that is not necessarily within any 
        level bounds. 
        '''
        if self.target > self._level:
            new = self.increase_propose()
        else:
            new = self.decrease_propose()
        self.value = new
        self._level = self.level(new)
        return new

    def new(self, compl, maxcomp=None):
        '''
        Compute a new value within the given complexities. 
        
        If called with one argument ``compl``, return a value within the bound
        ``compl - 1`` and ``compl``. If called with two argumets, these are 
        interpreted as the level bounds.   
        '''
        if maxcomp is None:
            return self.new(compl - 1, compl)
        else:
            mincomp, maxcomp = compl, maxcomp

        # Tries to consume the generator _new_worker() until it yields a 
        # non-null value. It attempt doing this a few times and raise a 
        # RuntimeError if the maximum number of attempts is reached
        for _ in range(20):
            for v in self._new_worker(mincomp, maxcomp):
                if v is not None:
                    return self.export(v)
            else:
                return self.new(compl, maxcomp)
        else:
            raise RuntimeError('maximum number of attempts reached')

    def _new_worker(self, mincomp, maxcomp):
        '''
        Worker function for the new() method.
        '''
        # 10 Attempts to create the correct number
        yield self._find_valid_new(mincomp, maxcomp, 10)

        # Reset current to a value in the allowed range and try to find a new
        # value
        for c in range(maxcomp, mincomp - 1, -1):
            if c in self._levels:
                self.value = random.choice(self._levels[c])
                self._curr_compl = self._results[self.value]
                yield self._find_valid_new(mincomp, maxcomp, 10)

        # Tries to increase level if all registered numbers have a too
        # low level
        max_c = max(self._levels)
        if maxcomp > max_c:
            self.value = random.choice(self._levels[max_c])
            for _ in range(10):
                self.increase_propose()
            yield self._find_valid_new(mincomp, maxcomp, 10)

        # Try to return an old value in the desired range
        for c in range(maxcomp, mincomp - 1, -1):
            if c in self._levels:
                yield random.choice(self._levels[c])

    def _find_valid_new(self, minc, maxc, N=10):
        '''
        Calls ``suggest_new()`` up to N times trying to obtain a value within
        the given level bounds.
        '''
        self.target = maxc
        for _ in range(N):
            num = self.suggest_new()
            num_c = self.register_value(num)
            if minc <= num_c <= maxc:
                return num
